package dnl.toolbox.xml;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.text.MessageFormat;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import dnl.toolbox.io.FileSystemResource;
import dnl.toolbox.io.Resource;

/**
 * A utility class that attempts to find the encoding of XML data.
 * 
 * @author Daniel Orr
 * 
 */
public class XmlEncodingDetector {
	private static final String UTF_8 = "UTF-8";
	private static final String UTF_16BE = "UTF-16BE";
	private static final String UTF_16LE = "UTF-16LE";
	private static final String UTF_16 = "UTF-16";

	/**
	 * Detects the encoding of the given XML <code>Resource</code>.
	 * 
	 * @param resource
	 *            a resource that refers to XML data.
	 * @param findNearestIfNone
	 *            if no <code>CharSet</code> exist that matches the exact one
	 *            specified in the XML, try to find nearest match (by name). For
	 *            example if ISO-8859-I is not found but ISO-8859 does than
	 *            return the latter.
	 * @return the detected encoding or <code>null</code> if none is found.
	 * @throws IOException
	 */
	public static String detectEncoding(File f, boolean findNearestIfNone) throws IOException {
		return detectEncoding(new FileSystemResource(f), findNearestIfNone);
	}
	
	public static String detectEncoding(Resource resource, boolean findNearestIfNone) throws IOException {
		InputStream inputStream = resource.getInputStream();
		byte[] buf = new byte[1024];
		inputStream.read(buf);
		inputStream.close();
		String encoding = detectEncoding(buf);
		if (findNearestIfNone && !Charset.isSupported(encoding)) {
			String closest = findClosest(encoding);
			if (closest != null) {
				return closest;
			}
		}
		return encoding;
	}

	
	
	private static String findClosest(String encoding) {
		Set<String> keySet = Charset.availableCharsets().keySet();
		for (String enc : keySet) {
			if (encoding.indexOf(enc.toUpperCase()) >= 0) {
				return enc;
			}
		}
		return null;
	}

	private static String detectEncoding(byte[] data) {
		String bomEnc = getBOMEncoding(data);
		String xmlGuessEnc = getXMLGuessEncoding(data);
		String xmlEnc = getXMLPrologEncoding(data, xmlGuessEnc);
		String encoding = calculateRawEncoding(bomEnc, xmlGuessEnc, xmlEnc);
		return encoding;
	}

	// returns the BOM in the stream, NULL if not present,
	// if there was BOM the in the stream it is consumed
	private static String getBOMEncoding(byte[] bytes) {
		String encoding = null;

		if (bytes[0] == 0xFE && bytes[1] == 0xFF) {
			encoding = UTF_16BE;
		} else if (bytes[0] == 0xFF && bytes[1] == 0xFE) {
			encoding = UTF_16LE;
		} else if (bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) {
			encoding = UTF_8;
		}
		return encoding;
	}

	// returns the best guess for the encoding by looking the first bytes of the
	// stream, '<?'
	private static String getXMLGuessEncoding(byte[] bytes) {
		String encoding = null;

		if (bytes[0] == 0x00 && bytes[1] == 0x3C && bytes[2] == 0x00 && bytes[3] == 0x3F) {
			encoding = UTF_16BE;
		} else if (bytes[0] == 0x3C && bytes[1] == 0x00 && bytes[2] == 0x3F && bytes[3] == 0x00) {
			encoding = UTF_16LE;
		} else if (bytes[0] == 0x3C && bytes[1] == 0x3F && bytes[2] == 0x78 && bytes[3] == 0x6D) {
			encoding = UTF_8;
		} else if (bytes[0] == 0xfffffffe && bytes[1] == 0xffffffff) {
			encoding = UTF_16;
		}
		return encoding;
	}

	private static final Pattern ENCODING_PATTERN = Pattern.compile("^<\\?xml.*encoding=\"(.*)\".*\\?>");

	// returns the encoding declared in the <?xml encoding=...?>, NULL if none
	private static String getXMLPrologEncoding(byte[] data, String guessedEnc) {
		String encoding = null;
		if (guessedEnc != null) {
			if (data.length > -1) {
				int endFirstLinePos = Math.min(data.length, 1024);
				for (int i = 0; i < 1024 && i < data.length; i++) {
					if (data[i] == '\n' || data[i] == '\r') {
						endFirstLinePos = i;
						break;
					}
				}
				String prolog = null;
				try {
					prolog = new String(data, 0, endFirstLinePos, guessedEnc);
				} catch (UnsupportedEncodingException e) {
					throw new RuntimeException(e);
				}
				Matcher m = ENCODING_PATTERN.matcher(prolog);
				encoding = (m.find()) ? m.group(1).toUpperCase() : null;
			}
		}
		return encoding;
	}

	private static String calculateRawEncoding(String bomEnc, String xmlGuessEnc, String xmlEnc) {
		String encoding;
		if (bomEnc == null) {
			if (xmlGuessEnc == null || xmlEnc == null) {
				encoding = UTF_8;
			} else if (xmlEnc.equals(UTF_16)
					&& (xmlGuessEnc.equals(UTF_16BE) || xmlGuessEnc.equals(UTF_16LE))) {
				encoding = xmlGuessEnc;
			} else {
				encoding = xmlEnc;
			}
		} else if (bomEnc.equals(UTF_8)) {
			if (xmlGuessEnc != null && !xmlGuessEnc.equals(UTF_8)) {
				throw new RuntimeException(RAW_EX_1.format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }));
			}
			if (xmlEnc != null && !xmlEnc.equals(UTF_8)) {
				throw new RuntimeException(RAW_EX_1.format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }));
			}
			encoding = UTF_8;
		} else if (bomEnc.equals(UTF_16BE) || bomEnc.equals(UTF_16LE)) {
			if (xmlGuessEnc != null && !xmlGuessEnc.equals(bomEnc)) {
				throw new RuntimeException(RAW_EX_1.format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }));
			}
			if (xmlEnc != null && !xmlEnc.equals(UTF_16) && !xmlEnc.equals(bomEnc)) {
				throw new RuntimeException(RAW_EX_1.format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }));
			}
			encoding = bomEnc;
		} else {
			throw new RuntimeException(RAW_EX_2.format(new Object[] { bomEnc, xmlGuessEnc, xmlEnc }));
		}
		return encoding;
	}

	private static final MessageFormat RAW_EX_1 = new MessageFormat(
			"Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] encoding mismatch");

	private static final MessageFormat RAW_EX_2 = new MessageFormat(
			"Invalid encoding, BOM [{0}] XML guess [{1}] XML prolog [{2}] unknown BOM");

}
